"""Base class for common speaker tasks."""
from __future__ import annotations

import asyncio
from collections.abc import Callable, Collection, Coroutine
import contextlib
import datetime
from functools import partial
import logging
import time
from typing import Any, cast

import defusedxml.ElementTree as ET
from soco.core import SoCo
from soco.events_base import Event as SonosEvent, SubscriptionBase
from soco.exceptions import SoCoException, SoCoUPnPException
from soco.plugins.plex import PlexPlugin
from soco.plugins.sharelink import ShareLinkPlugin
from soco.snapshot import Snapshot
from sonos_websocket import SonosWebsocket

from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
    async_dispatcher_connect,
    async_dispatcher_send,
    dispatcher_send,
)
from homeassistant.helpers.event import async_track_time_interval, track_time_interval
from homeassistant.util import dt as dt_util

from .alarms import SonosAlarms
from .const import (
    AVAILABILITY_TIMEOUT,
    BATTERY_SCAN_INTERVAL,
    DATA_SONOS,
    DOMAIN,
    SCAN_INTERVAL,
    SONOS_CHECK_ACTIVITY,
    SONOS_CREATE_ALARM,
    SONOS_CREATE_AUDIO_FORMAT_SENSOR,
    SONOS_CREATE_BATTERY,
    SONOS_CREATE_LEVELS,
    SONOS_CREATE_MEDIA_PLAYER,
    SONOS_CREATE_MIC_SENSOR,
    SONOS_CREATE_SWITCHES,
    SONOS_FALLBACK_POLL,
    SONOS_REBOOTED,
    SONOS_SPEAKER_ACTIVITY,
    SONOS_SPEAKER_ADDED,
    SONOS_STATE_PLAYING,
    SONOS_STATE_TRANSITIONING,
    SONOS_STATE_UPDATED,
    SONOS_VANISHED,
    SUBSCRIPTION_TIMEOUT,
)
from .exception import S1BatteryMissing, SonosSubscriptionsFailed, SonosUpdateError
from .favorites import SonosFavorites
from .helpers import soco_error
from .media import SonosMedia
from .statistics import ActivityStatistics, EventStatistics

NEVER_TIME = -1200.0
RESUB_COOLDOWN_SECONDS = 10.0
EVENT_CHARGING = {
    "CHARGING": True,
    "NOT_CHARGING": False,
}
SUBSCRIPTION_SERVICES = {
    "alarmClock",
    "avTransport",
    "contentDirectory",
    "deviceProperties",
    "renderingControl",
    "zoneGroupTopology",
}
SUPPORTED_VANISH_REASONS = ("powered off", "sleeping", "switch to bluetooth", "upgrade")
UNUSED_DEVICE_KEYS = ["SPID", "TargetRoomName"]


_LOGGER = logging.getLogger(__name__)


class SonosSpeaker:
    """Representation of a Sonos speaker."""

    def __init__(
        self,
        hass: HomeAssistant,
        soco: SoCo,
        speaker_info: dict[str, Any],
        zone_group_state_sub: SubscriptionBase | None,
    ) -> None:
        """Initialize a SonosSpeaker."""
        self.hass = hass
        self.soco = soco
        self.websocket: SonosWebsocket | None = None
        self.household_id: str = soco.household_id
        self.media = SonosMedia(hass, soco)
        self._plex_plugin: PlexPlugin | None = None
        self._share_link_plugin: ShareLinkPlugin | None = None
        self.available: bool = True

        # Device information
        self.hardware_version: str = speaker_info["hardware_version"]
        self.software_version: str = speaker_info["software_version"]
        self.mac_address: str = speaker_info["mac_address"]
        self.model_name: str = speaker_info["model_name"]
        self.model_number: str = speaker_info["model_number"]
        self.uid: str = speaker_info["uid"]
        self.version: str = speaker_info["display_version"]
        self.zone_name: str = speaker_info["zone_name"]

        # Subscriptions and events
        self.subscriptions_failed: bool = False
        self._subscriptions: list[SubscriptionBase] = []
        if zone_group_state_sub:
            zone_group_state_sub.callback = self.async_dispatch_event
            self._subscriptions.append(zone_group_state_sub)
        self._subscription_lock: asyncio.Lock | None = None
        self._event_dispatchers: dict[str, Callable] = {}
        self._last_activity: float = NEVER_TIME
        self._last_event_cache: dict[str, Any] = {}
        self.activity_stats: ActivityStatistics = ActivityStatistics(self.zone_name)
        self.event_stats: EventStatistics = EventStatistics(self.zone_name)
        self._resub_cooldown_expires_at: float | None = None

        # Scheduled callback handles
        self._poll_timer: Callable | None = None

        # Dispatcher handles
        self.dispatchers: list[Callable] = []

        # Battery
        self.battery_info: dict[str, Any] = {}
        self._last_battery_event: datetime.datetime | None = None
        self._battery_poll_timer: Callable | None = None

        # Volume / Sound
        self.volume: int | None = None
        self.muted: bool | None = None
        self.cross_fade: bool | None = None
        self.balance: tuple[int, int] | None = None
        self.bass: int | None = None
        self.treble: int | None = None
        self.loudness: bool | None = None

        # Home theater
        self.audio_delay: int | None = None
        self.dialog_level: bool | None = None
        self.night_mode: bool | None = None
        self.sub_enabled: bool | None = None
        self.sub_crossover: int | None = None
        self.sub_gain: int | None = None
        self.surround_enabled: bool | None = None
        self.surround_mode: bool | None = None
        self.surround_level: int | None = None
        self.music_surround_level: int | None = None

        # Misc features
        self.buttons_enabled: bool | None = None
        self.mic_enabled: bool | None = None
        self.status_light: bool | None = None

        # Grouping
        self.coordinator: SonosSpeaker | None = None
        self.sonos_group: list[SonosSpeaker] = [self]
        self.sonos_group_entities: list[str] = []
        self.soco_snapshot: Snapshot | None = None
        self.snapshot_group: list[SonosSpeaker] = []
        self._group_members_missing: set[str] = set()

    async def async_setup(self, entry: ConfigEntry) -> None:
        """Complete setup in async context."""
        self.websocket = SonosWebsocket(
            self.soco.ip_address,
            player_id=self.soco.uid,
            session=async_get_clientsession(self.hass),
        )
        dispatch_pairs: tuple[tuple[str, Callable[..., Any]], ...] = (
            (SONOS_CHECK_ACTIVITY, self.async_check_activity),
            (SONOS_SPEAKER_ADDED, self.update_group_for_uid),
            (f"{SONOS_REBOOTED}-{self.soco.uid}", self.async_rebooted),
            (f"{SONOS_SPEAKER_ACTIVITY}-{self.soco.uid}", self.speaker_activity),
            (f"{SONOS_VANISHED}-{self.soco.uid}", self.async_vanished),
        )

        for signal, target in dispatch_pairs:
            entry.async_on_unload(
                async_dispatcher_connect(
                    self.hass,
                    signal,
                    target,
                )
            )

    def setup(self, entry: ConfigEntry) -> None:
        """Run initial setup of the speaker."""
        self.media.play_mode = self.soco.play_mode
        self.update_volume()
        self.update_groups()
        if self.is_coordinator:
            self.media.poll_media()

        future = asyncio.run_coroutine_threadsafe(
            self.async_setup(entry), self.hass.loop
        )
        future.result(timeout=10)

        dispatcher_send(self.hass, SONOS_CREATE_LEVELS, self)

        if audio_format := self.soco.soundbar_audio_input_format:
            dispatcher_send(
                self.hass, SONOS_CREATE_AUDIO_FORMAT_SENSOR, self, audio_format
            )

        try:
            self.battery_info = self.fetch_battery_info()
        except SonosUpdateError:
            _LOGGER.debug("No battery available for %s", self.zone_name)
        else:
            # Battery events can be infrequent, polling is still necessary
            self._battery_poll_timer = track_time_interval(
                self.hass, self.async_poll_battery, BATTERY_SCAN_INTERVAL
            )
            dispatcher_send(self.hass, SONOS_CREATE_BATTERY, self)

        if (mic_enabled := self.soco.mic_enabled) is not None:
            self.mic_enabled = mic_enabled
            dispatcher_send(self.hass, SONOS_CREATE_MIC_SENSOR, self)

        if new_alarms := [
            alarm.alarm_id for alarm in self.alarms if alarm.zone.uid == self.soco.uid
        ]:
            dispatcher_send(self.hass, SONOS_CREATE_ALARM, self, new_alarms)

        dispatcher_send(self.hass, SONOS_CREATE_SWITCHES, self)

        self._event_dispatchers = {
            "AlarmClock": self.async_dispatch_alarms,
            "AVTransport": self.async_dispatch_media_update,
            "ContentDirectory": self.async_dispatch_favorites,
            "DeviceProperties": self.async_dispatch_device_properties,
            "RenderingControl": self.async_update_volume,
            "ZoneGroupTopology": self.async_update_groups,
        }

        dispatcher_send(self.hass, SONOS_CREATE_MEDIA_PLAYER, self)
        dispatcher_send(self.hass, SONOS_SPEAKER_ADDED, self.soco.uid)

        self.hass.create_task(self.async_subscribe())

    #
    # Entity management
    #
    def write_entity_states(self) -> None:
        """Write states for associated SonosEntity instances."""
        dispatcher_send(self.hass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}")

    @callback
    def async_write_entity_states(self) -> None:
        """Write states for associated SonosEntity instances."""
        async_dispatcher_send(self.hass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}")

    #
    # Properties
    #
    @property
    def alarms(self) -> SonosAlarms:
        """Return the SonosAlarms instance for this household."""
        return self.hass.data[DATA_SONOS].alarms[self.household_id]

    @property
    def favorites(self) -> SonosFavorites:
        """Return the SonosFavorites instance for this household."""
        return self.hass.data[DATA_SONOS].favorites[self.household_id]

    @property
    def is_coordinator(self) -> bool:
        """Return true if player is a coordinator."""
        return self.coordinator is None

    @property
    def plex_plugin(self) -> PlexPlugin:
        """Cache the PlexPlugin instance for this speaker."""
        if not self._plex_plugin:
            self._plex_plugin = PlexPlugin(self.soco)
        return self._plex_plugin

    @property
    def share_link(self) -> ShareLinkPlugin:
        """Cache the ShareLinkPlugin instance for this speaker."""
        if not self._share_link_plugin:
            self._share_link_plugin = ShareLinkPlugin(self.soco)
        return self._share_link_plugin

    @property
    def subscription_address(self) -> str:
        """Return the current subscription callback address."""
        assert len(self._subscriptions) > 0
        addr, port = self._subscriptions[0].event_listener.address
        return ":".join([addr, str(port)])

    @property
    def missing_subscriptions(self) -> set[str]:
        """Return a list of missing service subscriptions."""
        subscribed_services = {sub.service.service_type for sub in self._subscriptions}
        return SUBSCRIPTION_SERVICES - subscribed_services

    #
    # Subscription handling and event dispatchers
    #
    def log_subscription_result(
        self, result: Any, event: str, level: int = logging.DEBUG
    ) -> None:
        """Log a message if a subscription action (create/renew/stop) results in an exception."""
        if not isinstance(result, Exception):
            return

        if isinstance(result, asyncio.exceptions.TimeoutError):
            message = "Request timed out"
            exc_info = None
        else:
            message = str(result)
            exc_info = result if not str(result) else None

        _LOGGER.log(
            level,
            "%s failed for %s: %s",
            event,
            self.zone_name,
            message,
            exc_info=exc_info,
        )

    async def async_subscribe(self) -> None:
        """Initiate event subscriptions under an async lock."""
        if not self._subscription_lock:
            self._subscription_lock = asyncio.Lock()

        async with self._subscription_lock:
            try:
                await self._async_subscribe()
            except SonosSubscriptionsFailed:
                _LOGGER.warning("Creating subscriptions failed for %s", self.zone_name)
                await self._async_offline()

    async def _async_subscribe(self) -> None:
        """Create event subscriptions."""
        subscriptions = [
            self._subscribe(getattr(self.soco, service), self.async_dispatch_event)
            for service in self.missing_subscriptions
        ]
        if not subscriptions:
            return

        _LOGGER.debug("Creating subscriptions for %s", self.zone_name)
        results = await asyncio.gather(*subscriptions, return_exceptions=True)
        for result in results:
            self.log_subscription_result(
                result, "Creating subscription", logging.WARNING
            )

        if any(isinstance(result, Exception) for result in results):
            raise SonosSubscriptionsFailed

        # Create a polling task in case subscriptions fail
        # or callback events do not arrive
        if not self._poll_timer:
            self._poll_timer = async_track_time_interval(
                self.hass,
                partial(
                    async_dispatcher_send,
                    self.hass,
                    f"{SONOS_FALLBACK_POLL}-{self.soco.uid}",
                ),
                SCAN_INTERVAL,
            )

    async def _subscribe(
        self, target: SubscriptionBase, sub_callback: Callable
    ) -> None:
        """Create a Sonos subscription."""
        subscription = await target.subscribe(
            auto_renew=True, requested_timeout=SUBSCRIPTION_TIMEOUT
        )
        subscription.callback = sub_callback
        subscription.auto_renew_fail = self.async_renew_failed
        self._subscriptions.append(subscription)

    async def async_unsubscribe(self) -> None:
        """Cancel all subscriptions."""
        if not self._subscriptions:
            return
        _LOGGER.debug("Unsubscribing from events for %s", self.zone_name)
        results = await asyncio.gather(
            *(subscription.unsubscribe() for subscription in self._subscriptions),
            return_exceptions=True,
        )
        for result in results:
            self.log_subscription_result(result, "Unsubscribe")
        self._subscriptions = []

    @callback
    def async_renew_failed(self, exception: Exception) -> None:
        """Handle a failed subscription renewal."""
        self.hass.async_create_task(self._async_renew_failed(exception))

    async def _async_renew_failed(self, exception: Exception) -> None:
        """Mark the speaker as offline after a subscription renewal failure.

        This is to reset the state to allow a future clean subscription attempt.
        """
        if not self.available:
            return

        self.log_subscription_result(exception, "Subscription renewal", logging.WARNING)
        await self.async_offline()

    @callback
    def async_dispatch_event(self, event: SonosEvent) -> None:
        """Handle callback event and route as needed."""
        if self._poll_timer:
            _LOGGER.debug(
                "Received event, cancelling poll timer for %s", self.zone_name
            )
            self._poll_timer()
            self._poll_timer = None

        self.speaker_activity(f"{event.service.service_type} subscription")
        self.event_stats.receive(event)

        # Skip if this update is an unchanged subset of the previous event
        if last_event := self._last_event_cache.get(event.service.service_type):
            if event.variables.items() <= last_event.items():
                self.event_stats.duplicate(event)
                return

        # Save most recently processed event variables for cache and diagnostics
        self._last_event_cache[event.service.service_type] = event.variables
        dispatcher = self._event_dispatchers[event.service.service_type]
        dispatcher(event)

    @callback
    def async_dispatch_alarms(self, event: SonosEvent) -> None:
        """Add the soco instance associated with the event to the callback."""
        if "alarm_list_version" not in event.variables:
            return
        self.hass.async_create_task(self.alarms.async_process_event(event, self))

    @callback
    def async_dispatch_device_properties(self, event: SonosEvent) -> None:
        """Update device properties from an event."""
        self.event_stats.process(event)
        self.hass.async_create_task(self.async_update_device_properties(event))

    async def async_update_device_properties(self, event: SonosEvent) -> None:
        """Update device properties from an event."""
        if "mic_enabled" in event.variables:
            mic_exists = self.mic_enabled is not None
            self.mic_enabled = bool(int(event.variables["mic_enabled"]))
            if not mic_exists:
                async_dispatcher_send(self.hass, SONOS_CREATE_MIC_SENSOR, self)

        if more_info := event.variables.get("more_info"):
            await self.async_update_battery_info(more_info)

        self.async_write_entity_states()

    @callback
    def async_dispatch_favorites(self, event: SonosEvent) -> None:
        """Add the soco instance associated with the event to the callback."""
        if "favorites_update_id" not in event.variables:
            return
        if "container_update_i_ds" not in event.variables:
            return
        self.hass.async_create_task(self.favorites.async_process_event(event, self))

    @callback
    def async_dispatch_media_update(self, event: SonosEvent) -> None:
        """Update information about currently playing media from an event."""
        # The new coordinator can be provided in a media update event but
        # before the ZoneGroupState updates. If this happens the playback
        # state will be incorrect and should be ignored. Switching to the
        # new coordinator will use its media. The regrouping process will
        # be completed during the next ZoneGroupState update.
        av_transport_uri = event.variables.get("av_transport_uri", "")
        current_track_uri = event.variables.get("current_track_uri", "")
        if av_transport_uri == current_track_uri and av_transport_uri.startswith(
            "x-rincon:"
        ):
            new_coordinator_uid = av_transport_uri.split(":")[-1]
            if new_coordinator_speaker := self.hass.data[DATA_SONOS].discovered.get(
                new_coordinator_uid
            ):
                _LOGGER.debug(
                    "Media update coordinator (%s) received for %s",
                    new_coordinator_speaker.zone_name,
                    self.zone_name,
                )
                self.coordinator = new_coordinator_speaker
            else:
                _LOGGER.debug(
                    "Media update coordinator (%s) for %s not yet available",
                    new_coordinator_uid,
                    self.zone_name,
                )
            return

        if crossfade := event.variables.get("current_crossfade_mode"):
            crossfade = bool(int(crossfade))
            if self.cross_fade != crossfade:
                self.cross_fade = crossfade
                self.async_write_entity_states()

        # Missing transport_state indicates a transient error
        if (new_status := event.variables.get("transport_state")) is None:
            return

        # Ignore transitions, we should get the target state soon
        if new_status == SONOS_STATE_TRANSITIONING:
            return

        self.event_stats.process(event)
        self.hass.async_add_executor_job(
            self.media.update_media_from_event, event.variables
        )

    @callback
    def async_update_volume(self, event: SonosEvent) -> None:
        """Update information about currently volume settings."""
        self.event_stats.process(event)
        variables = event.variables

        if "volume" in variables:
            volume = variables["volume"]
            self.volume = int(volume["Master"])
            if "LF" in volume and "RF" in volume:
                self.balance = (int(volume["LF"]), int(volume["RF"]))

        if "mute" in variables:
            self.muted = variables["mute"]["Master"] == "1"

        if loudness := variables.get("loudness"):
            self.loudness = loudness["Master"] == "1"

        for bool_var in (
            "dialog_level",
            "night_mode",
            "sub_enabled",
            "surround_enabled",
            "surround_mode",
        ):
            if bool_var in variables:
                setattr(self, bool_var, variables[bool_var] == "1")

        for int_var in (
            "audio_delay",
            "bass",
            "treble",
            "sub_crossover",
            "sub_gain",
            "surround_level",
            "music_surround_level",
        ):
            if int_var in variables:
                setattr(self, int_var, variables[int_var])

        self.async_write_entity_states()

    #
    # Speaker availability methods
    #
    @soco_error()
    def ping(self) -> None:
        """Test device availability. Failure will raise SonosUpdateError."""
        self.soco.renderingControl.GetVolume(
            [("InstanceID", 0), ("Channel", "Master")], timeout=1
        )

    @callback
    def speaker_activity(self, source: str) -> None:
        """Track the last activity on this speaker, set availability and resubscribe."""
        if self._resub_cooldown_expires_at:
            if time.monotonic() < self._resub_cooldown_expires_at:
                _LOGGER.debug(
                    "Activity on %s from %s while in cooldown, ignoring",
                    self.zone_name,
                    source,
                )
                return
            self._resub_cooldown_expires_at = None

        _LOGGER.debug("Activity on %s from %s", self.zone_name, source)
        self._last_activity = time.monotonic()
        self.activity_stats.activity(source, self._last_activity)
        was_available = self.available
        self.available = True
        if not was_available:
            self.async_write_entity_states()
            self.hass.async_create_task(self.async_subscribe())

    @callback
    def async_check_activity(self, now: datetime.datetime) -> None:
        """Validate availability of the speaker based on recent activity."""
        if not self.available:
            return
        if time.monotonic() - self._last_activity < AVAILABILITY_TIMEOUT:
            return
        # Ensure the ping is canceled at shutdown
        self.hass.async_create_background_task(
            self._async_check_activity(), f"sonos {self.uid} {self.zone_name} ping"
        )

    async def _async_check_activity(self) -> None:
        """Validate availability of the speaker based on recent activity."""
        try:
            await self.hass.async_add_executor_job(self.ping)
        except SonosUpdateError:
            _LOGGER.warning(
                "No recent activity and cannot reach %s, marking unavailable",
                self.zone_name,
            )
            await self.async_offline()

    async def async_offline(self) -> None:
        """Handle removal of speaker when unavailable."""
        assert self._subscription_lock is not None
        async with self._subscription_lock:
            await self._async_offline()

    async def _async_offline(self) -> None:
        """Handle removal of speaker when unavailable."""
        if not self.available:
            return

        if self._resub_cooldown_expires_at is None and not self.hass.is_stopping:
            self._resub_cooldown_expires_at = time.monotonic() + RESUB_COOLDOWN_SECONDS
            _LOGGER.debug("Starting resubscription cooldown for %s", self.zone_name)

        self.available = False
        self.async_write_entity_states()

        self._share_link_plugin = None

        if self._poll_timer:
            self._poll_timer()
            self._poll_timer = None

        await self.async_unsubscribe()

        self.hass.data[DATA_SONOS].discovery_known.discard(self.soco.uid)

    async def async_vanished(self, reason: str) -> None:
        """Handle removal of speaker when marked as vanished."""
        if not self.available:
            return
        _LOGGER.debug(
            "%s has vanished (%s), marking unavailable", self.zone_name, reason
        )
        await self.async_offline()

    async def async_rebooted(self) -> None:
        """Handle a detected speaker reboot."""
        _LOGGER.debug("%s rebooted, reconnecting", self.zone_name)
        await self.async_offline()
        self.speaker_activity("reboot")

    #
    # Battery management
    #
    @soco_error()
    def fetch_battery_info(self) -> dict[str, Any]:
        """Fetch battery_info for the speaker."""
        battery_info = self.soco.get_battery_info()
        if not battery_info:
            # S1 firmware returns an empty payload
            raise S1BatteryMissing
        return battery_info

    async def async_update_battery_info(self, more_info: str) -> None:
        """Update battery info using a SonosEvent payload value."""
        battery_dict = dict(x.split(":") for x in more_info.split(","))
        for unused in UNUSED_DEVICE_KEYS:
            battery_dict.pop(unused, None)
        if not battery_dict:
            return
        if "BattChg" not in battery_dict:
            _LOGGER.debug(
                (
                    "Unknown device properties update for %s (%s),"
                    " please report an issue: '%s'"
                ),
                self.zone_name,
                self.model_name,
                more_info,
            )
            return

        self._last_battery_event = dt_util.utcnow()

        is_charging = EVENT_CHARGING[battery_dict["BattChg"]]

        if not self._battery_poll_timer:
            # Battery info received for an S1 speaker
            new_battery = not self.battery_info
            self.battery_info.update(
                {
                    "Level": int(battery_dict["BattPct"]),
                    "PowerSource": "EXTERNAL" if is_charging else "BATTERY",
                }
            )
            if new_battery:
                _LOGGER.warning(
                    "S1 firmware detected on %s, battery info may update infrequently",
                    self.zone_name,
                )
                async_dispatcher_send(self.hass, SONOS_CREATE_BATTERY, self)
            return

        if is_charging == self.charging:
            self.battery_info.update({"Level": int(battery_dict["BattPct"])})
        elif not is_charging:
            # Avoid polling the speaker if possible
            self.battery_info["PowerSource"] = "BATTERY"
        else:
            # Poll to obtain current power source not provided by event
            try:
                self.battery_info = await self.hass.async_add_executor_job(
                    self.fetch_battery_info
                )
            except SonosUpdateError as err:
                _LOGGER.debug("Could not request current power source: %s", err)

    @property
    def power_source(self) -> str | None:
        """Return the name of the current power source.

        Observed to be either BATTERY or SONOS_CHARGING_RING or USB_POWER.

        May be an empty dict if used with an S1 Move.
        """
        return self.battery_info.get("PowerSource")

    @property
    def charging(self) -> bool | None:
        """Return the charging status of the speaker."""
        if self.power_source:
            return self.power_source != "BATTERY"
        return None

    async def async_poll_battery(self, now: datetime.datetime | None = None) -> None:
        """Poll the device for the current battery state."""
        if not self.available:
            return

        if (
            self._last_battery_event
            and dt_util.utcnow() - self._last_battery_event < BATTERY_SCAN_INTERVAL
        ):
            return

        try:
            self.battery_info = await self.hass.async_add_executor_job(
                self.fetch_battery_info
            )
        except SonosUpdateError as err:
            _LOGGER.debug("Could not poll battery info: %s", err)
        else:
            self.async_write_entity_states()

    #
    # Group management
    #
    def update_groups(self) -> None:
        """Update group topology when polling."""
        self.hass.add_job(self.create_update_groups_coro())

    def update_group_for_uid(self, uid: str) -> None:
        """Update group topology if uid is missing."""
        if uid not in self._group_members_missing:
            return
        missing_zone = self.hass.data[DATA_SONOS].discovered[uid].zone_name
        _LOGGER.debug(
            "%s was missing, adding to %s group", missing_zone, self.zone_name
        )
        self.update_groups()

    @callback
    def async_update_groups(self, event: SonosEvent) -> None:
        """Handle callback for topology change event."""
        if xml := event.variables.get("zone_group_state"):
            zgs = ET.fromstring(xml)
            for vanished_device in zgs.find("VanishedDevices") or []:
                if (
                    reason := vanished_device.get("Reason")
                ) not in SUPPORTED_VANISH_REASONS:
                    _LOGGER.debug(
                        "Ignoring %s marked %s as vanished with reason: %s",
                        self.zone_name,
                        vanished_device.get("ZoneName"),
                        reason,
                    )
                    continue
                uid = vanished_device.get("UUID")
                async_dispatcher_send(
                    self.hass,
                    f"{SONOS_VANISHED}-{uid}",
                    reason,
                )

        if "zone_player_uui_ds_in_group" not in event.variables:
            return
        self.event_stats.process(event)
        self.hass.async_create_task(self.create_update_groups_coro(event))

    def create_update_groups_coro(self, event: SonosEvent | None = None) -> Coroutine:
        """Handle callback for topology change event."""

        def _get_soco_group() -> list[str]:
            """Ask SoCo cache for existing topology."""
            coordinator_uid = self.soco.uid
            joined_uids = []

            with contextlib.suppress(OSError, SoCoException):
                if self.soco.group and self.soco.group.coordinator:
                    coordinator_uid = self.soco.group.coordinator.uid
                    joined_uids = [
                        p.uid
                        for p in self.soco.group.members
                        if p.uid != coordinator_uid and p.is_visible
                    ]

            return [coordinator_uid] + joined_uids

        async def _async_extract_group(event: SonosEvent | None) -> list[str]:
            """Extract group layout from a topology event."""
            group = event and event.zone_player_uui_ds_in_group
            if group:
                assert isinstance(group, str)
                return group.split(",")

            return await self.hass.async_add_executor_job(_get_soco_group)

        @callback
        def _async_regroup(group: list[str]) -> None:
            """Rebuild internal group layout."""
            if (
                group == [self.soco.uid]
                and self.sonos_group == [self]
                and self.sonos_group_entities
            ):
                # Skip updating existing single speakers in polling mode
                return

            entity_registry = er.async_get(self.hass)
            sonos_group = []
            sonos_group_entities = []

            for uid in group:
                speaker = self.hass.data[DATA_SONOS].discovered.get(uid)
                if speaker:
                    self._group_members_missing.discard(uid)
                    sonos_group.append(speaker)
                    entity_id = cast(
                        str, entity_registry.async_get_entity_id(MP_DOMAIN, DOMAIN, uid)
                    )
                    sonos_group_entities.append(entity_id)
                else:
                    self._group_members_missing.add(uid)
                    _LOGGER.debug(
                        "%s group member unavailable (%s), will try again",
                        self.zone_name,
                        uid,
                    )
                    return

            if self.sonos_group_entities == sonos_group_entities:
                # Useful in polling mode for speakers with stereo pairs or surrounds
                # as those "invisible" speakers will bypass the single speaker check
                return

            self.coordinator = None
            self.sonos_group = sonos_group
            self.sonos_group_entities = sonos_group_entities
            self.async_write_entity_states()

            for joined_uid in group[1:]:
                joined_speaker: SonosSpeaker = self.hass.data[
                    DATA_SONOS
                ].discovered.get(joined_uid)
                if joined_speaker:
                    joined_speaker.coordinator = self
                    joined_speaker.sonos_group = sonos_group
                    joined_speaker.sonos_group_entities = sonos_group_entities
                    joined_speaker.async_write_entity_states()

            _LOGGER.debug("Regrouped %s: %s", self.zone_name, self.sonos_group_entities)

        async def _async_handle_group_event(event: SonosEvent | None) -> None:
            """Get async lock and handle event."""

            async with self.hass.data[DATA_SONOS].topology_condition:
                group = await _async_extract_group(event)

                if self.soco.uid == group[0]:
                    _async_regroup(group)

                    self.hass.data[DATA_SONOS].topology_condition.notify_all()

        return _async_handle_group_event(event)

    @soco_error()
    def join(self, speakers: list[SonosSpeaker]) -> list[SonosSpeaker]:
        """Form a group with other players."""
        if self.coordinator:
            self.unjoin()
            group = [self]
        else:
            group = self.sonos_group.copy()

        for speaker in speakers:
            if speaker.soco.uid != self.soco.uid:
                if speaker not in group:
                    speaker.soco.join(self.soco)
                    speaker.coordinator = self
                    group.append(speaker)

        return group

    @staticmethod
    async def join_multi(
        hass: HomeAssistant,
        master: SonosSpeaker,
        speakers: list[SonosSpeaker],
    ) -> None:
        """Form a group with other players."""
        async with hass.data[DATA_SONOS].topology_condition:
            group: list[SonosSpeaker] = await hass.async_add_executor_job(
                master.join, speakers
            )
            await SonosSpeaker.wait_for_groups(hass, [group])

    @soco_error()
    def unjoin(self) -> None:
        """Unjoin the player from a group."""
        if self.sonos_group == [self]:
            return
        self.soco.unjoin()
        self.coordinator = None

    @staticmethod
    async def unjoin_multi(hass: HomeAssistant, speakers: list[SonosSpeaker]) -> None:
        """Unjoin several players from their group."""

        def _unjoin_all(speakers: list[SonosSpeaker]) -> None:
            """Sync helper."""
            # Detach all joined speakers first to prevent inheritance of queues
            coordinators = [s for s in speakers if s.is_coordinator]
            joined_speakers = [s for s in speakers if not s.is_coordinator]

            for speaker in joined_speakers + coordinators:
                speaker.unjoin()

        async with hass.data[DATA_SONOS].topology_condition:
            await hass.async_add_executor_job(_unjoin_all, speakers)
            await SonosSpeaker.wait_for_groups(hass, [[s] for s in speakers])

    @soco_error()
    def snapshot(self, with_group: bool) -> None:
        """Snapshot the state of a player."""
        self.soco_snapshot = Snapshot(self.soco)
        self.soco_snapshot.snapshot()
        if with_group:
            self.snapshot_group = self.sonos_group.copy()
        else:
            self.snapshot_group = []

    @staticmethod
    async def snapshot_multi(
        hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool
    ) -> None:
        """Snapshot all the speakers and optionally their groups."""

        def _snapshot_all(speakers: Collection[SonosSpeaker]) -> None:
            """Sync helper."""
            for speaker in speakers:
                speaker.snapshot(with_group)

        # Find all affected players
        speakers_set = set(speakers)
        if with_group:
            for speaker in list(speakers_set):
                speakers_set.update(speaker.sonos_group)

        async with hass.data[DATA_SONOS].topology_condition:
            await hass.async_add_executor_job(_snapshot_all, speakers_set)

    @soco_error()
    def restore(self) -> None:
        """Restore a snapshotted state to a player."""
        try:
            assert self.soco_snapshot is not None
            self.soco_snapshot.restore()
        except (TypeError, AssertionError, AttributeError, SoCoException) as ex:
            # Can happen if restoring a coordinator onto a current group member
            _LOGGER.warning("Error on restore %s: %s", self.zone_name, ex)

        self.soco_snapshot = None
        self.snapshot_group = []

    @staticmethod
    async def restore_multi(
        hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool
    ) -> None:
        """Restore snapshots for all the speakers."""

        def _restore_groups(
            speakers: set[SonosSpeaker], with_group: bool
        ) -> list[list[SonosSpeaker]]:
            """Pause all current coordinators and restore groups."""
            for speaker in (s for s in speakers if s.is_coordinator):
                if (
                    speaker.media.playback_status == SONOS_STATE_PLAYING
                    and "Pause" in speaker.soco.available_actions
                ):
                    try:
                        speaker.soco.pause()
                    except SoCoUPnPException as exc:
                        _LOGGER.debug(
                            "Pause failed during restore of %s: %s",
                            speaker.zone_name,
                            speaker.soco.available_actions,
                            exc_info=exc,
                        )

            groups: list[list[SonosSpeaker]] = []
            if not with_group:
                return groups

            # Unjoin non-coordinator speakers not contained in the desired snapshot group
            #
            # If a coordinator is unjoined from its group, another speaker from the group
            # will inherit the coordinator's playqueue and its own playqueue will be lost
            speakers_to_unjoin = set()
            for speaker in speakers:
                if speaker.sonos_group == speaker.snapshot_group:
                    continue

                speakers_to_unjoin.update(
                    {
                        s
                        for s in speaker.sonos_group[1:]
                        if s not in speaker.snapshot_group
                    }
                )

            for speaker in speakers_to_unjoin:
                speaker.unjoin()

            # Bring back the original group topology
            for speaker in (s for s in speakers if s.snapshot_group):
                assert len(speaker.snapshot_group)
                if speaker.snapshot_group[0] == speaker:
                    if speaker.snapshot_group not in (speaker.sonos_group, [speaker]):
                        speaker.join(speaker.snapshot_group)
                    groups.append(speaker.snapshot_group.copy())

            return groups

        def _restore_players(speakers: Collection[SonosSpeaker]) -> None:
            """Restore state of all players."""
            for speaker in (s for s in speakers if not s.is_coordinator):
                speaker.restore()

            for speaker in (s for s in speakers if s.is_coordinator):
                speaker.restore()

        # Find all affected players
        speakers_set = {s for s in speakers if s.soco_snapshot}
        if missing_snapshots := set(speakers) - speakers_set:
            raise HomeAssistantError(
                "Restore failed, speakers are missing snapshots:"
                f" {[s.zone_name for s in missing_snapshots]}"
            )

        if with_group:
            for speaker in [s for s in speakers_set if s.snapshot_group]:
                assert len(speaker.snapshot_group)
                speakers_set.update(speaker.snapshot_group)

        async with hass.data[DATA_SONOS].topology_condition:
            groups = await hass.async_add_executor_job(
                _restore_groups, speakers_set, with_group
            )
            await SonosSpeaker.wait_for_groups(hass, groups)
            await hass.async_add_executor_job(_restore_players, speakers_set)

    @staticmethod
    async def wait_for_groups(
        hass: HomeAssistant, groups: list[list[SonosSpeaker]]
    ) -> None:
        """Wait until all groups are present, or timeout."""

        def _test_groups(groups: list[list[SonosSpeaker]]) -> bool:
            """Return whether all groups exist now."""
            for group in groups:
                coordinator = group[0]

                # Test that coordinator is coordinating
                current_group = coordinator.sonos_group
                if coordinator != current_group[0]:
                    return False

                # Test that joined members match
                if set(group[1:]) != set(current_group[1:]):
                    return False

            return True

        try:
            async with asyncio.timeout(5):
                while not _test_groups(groups):
                    await hass.data[DATA_SONOS].topology_condition.wait()
        except asyncio.TimeoutError:
            _LOGGER.warning("Timeout waiting for target groups %s", groups)

        any_speaker = next(iter(hass.data[DATA_SONOS].discovered.values()))
        any_speaker.soco.zone_group_state.clear_cache()

    #
    # Media and playback state handlers
    #
    @soco_error()
    def update_volume(self) -> None:
        """Update information about current volume settings."""
        self.volume = self.soco.volume
        self.muted = self.soco.mute
